Skip to content

Dev - #49

Merged
otto-link merged 17 commits into
mainfrom
dev
Sep 4, 2026
Merged

Dev#49
otto-link merged 17 commits into
mainfrom
dev

Conversation

@otto-link

Copy link
Copy Markdown
Member

No description provided.

otto-link and others added 17 commits August 20, 2026 19:53
…n a ContainerGroup (#40)

- Add value_changed_event to AbstractAttribute for untyped notification
- Add attribute_added and attribute_removed events to AttributeContainer
- Add shared attribute detection (shared_attributes, is_shared) to ContainerGroup
- Add synchronization controls (set_synchronized, is_synchronized, synchronize_all, clear_synchronizations, sync_attribute)
- Support real-time 2-way value synchronization with recursion guard
- Support dynamic container/attribute addition and removal
- Support JSON serialization/deserialization of synchronized attributes
- Add comprehensive unit tests in test_container_group_sync.cpp
…onize-shared-attributes-across-containers-in-a-groupcontainer

feat: add option to synchronize shared attributes across containers i…
Adds a design layer under meta_qt so a host can supply an alternative look
for attribute rows without forking the renderer, and so a second look costs
a registration rather than an edit to shared dispatch.

Today a row is produced by three hardcoded cascades: an if-chain on typeid,
a WidgetRenderer<T> specialisation, then an if-chain on the widget_type
string. WidgetRenderer<float>::render() also does five jobs at once -- read
metadata, pick a control, build it, wire control->attribute, wire
attribute->control -- and the last two are copy-pasted into every branch.
A second visual variant therefore means duplicating the model wiring, which
is where the subtle bugs live (a sync landing mid-drag, signal feedback
loops, subscription lifetime).

Four layers, all additive:

- ui/theme.hpp      Theme as a value type plus a registry, not process-wide
                    globals; derived colours are exposed as formulas so an
                    accent change propagates. Two colourways ship so the
                    mechanism has more than one occupant.
- ui/control.hpp    ControlBase / Control<T>. Wheel events are ignored unless
                    focused (wheelEvent is final; override handle_wheel), and
                    the editing flag is only settable via begin_edit/end_edit
                    alongside the matching signal.
- ui/binding.hpp    bind<T>() -- written once per type, never per design.
                    Owns the sync-vs-edit guard, signal blocking and
                    subscription lifetime for every control of that type.
- ui/design_registry.hpp
                    (design, type, widget_type) -> factory, resolving exact,
                    then wildcard, then falling back to the stock renderer.
                    Controls provide can_render() so an attribute a design
                    cannot honour (a rail with no min/max) degrades to stock
                    rather than rendering a [0, 0] range.

designs/industrial/ supplies two controls as proof the seam is real: a float
slider and a bool toggle. An unregistered design name resolves nothing and
every row falls back, so selecting a design that does not exist yields the
unmodified stock panel.

container_widget gains one ContainerRenderOptions field, row_renderer,
threaded to the three row-building sites. Left empty it calls qt::render()
exactly as before, so behaviour is unchanged unless a host opts in.
Follow-up to the design layer: "stock" becomes a flavour registered like any
other rather than a fallback hardcoded into the dispatcher, so every design
sits at the same level in the hierarchy.

designs/stock wraps the existing WidgetRenderer<T> for each supported type,
registered under the wildcard widget_type since WidgetRenderer<T> already
resolves widget_type itself. No stock widget is rewritten and
WidgetRenderer<T> stays a public compile-time extension point -- only its use
as a dispatcher goes away. Splitting the inner widget_type if-chains into
separate entries would remove those too, but that is a rewrite rather than a
registration and is left for later.

render_row() now reduces to a single DesignRegistry::render() call with no
hardcoded renderer behind it.

Resolution gains a fallback chain, expressed as data rather than as a special
case in code: set_fallback("industrial", "stock") means a widget type the
industrial design has not covered yet resolves through stock instead of
rendering nothing. Without it a design under construction would drop every
unported row, which is what the old hardcoded fallback existed to prevent.
Leave the fallback unset and an unregistered type renders nothing, as
expected. Cycles are ignored rather than followed, and a factory returning
nullptr -- can_render() declining -- resumes the same walk.
The design only worked against the one colour scheme it was sampled from:
every surface, hairline and ink value was a hardcoded hex. Theme::from_palette()
derives them from a QPalette instead, so the look sits on top of whatever
scheme the host runs.

Two derivation rules, picked so the same code works on light and dark schemes
without branching on which one it is:

- Surfaces blend towards black or white. A recessed rail well is darker and a
  raised bevel lighter in both schemes, whereas darker() on a near-black window
  barely moves.
- Dimmed ink blends towards the window colour, so "less prominent" resolves to
  darker on a light scheme and lighter on a dark one by construction.

Accent comes from QPalette::Highlight rather than being imposed. Group accents
stay hardcoded on purpose: they encode which family an operation belongs to, so
they have to stay distinguishable from each other rather than track a host
accent.

ThemeRegistry now defaults to "palette", built lazily from the application
palette because the registry is a static singleton that may be constructed
before QApplication exists. The sampled colourway remains available as
"industrial-dark" for pinning the reference look.

The struct's member initialisers still hold the reference values. They document
what was sampled and keep a default-constructed Theme paintable, but they are no
longer the intended source of colour.
SliderInt covers roughly 13% of the rows in a Hesiod node panel once Seed is
counted, and was the largest remaining stock element after float and bool.

Rather than copy ParamSlider, the row geometry and painting move into
slider_chrome and both sliders call it. The two rows cannot drift apart, and
the duplication is limited to value semantics, which genuinely differ: the int
rail quantises to whole values, the glide drives the painted position only, and
the readout is set up front. A float slider derives its value from the
animation instead, which for an int would briefly show something the model
cannot hold. One wheel notch is one unit rather than a percentage of the range.

Section chrome needs a different seam. A section is not bound to an attribute,
so it cannot resolve through the design registry; ContainerRenderOptions gains
a section_factory alongside row_renderer, empty meaning the stock section. The
industrial factory returns a stock CollapsibleSection wearing a theme-derived
stylesheet rather than a subclass, which restyles the header without widening
CollapsibleSection's interface.

That header is a checked QToolButton, which most styles paint in the platform
highlight colour. It is why an unstyled panel showed blue bars between grey
rows.
Combo boxes now use a popup of our own rather than QComboBox. The stock popup
composites a frame and an item view that are styled separately, so mid-open you
see one surface and once it settles another, and the two rarely agree on
colour. Painting the whole surface in one pass is the only way to make it read
as a single thing. Covers EnumComboBox, ComboBox and ButtonGrid, roughly 6% of
the rows.

The four Qt popup traps are handled explicitly, since each looks like a
different bug: it opens on release, because opening on press means the matching
release lands outside and dismisses it; an outside press is closed by hand,
because overriding mousePressEvent suppresses Qt's built-in dismissal; the
close is timestamped in hideEvent rather than destroyed(), which fires an
event-loop pass too late; and a click arriving within 200ms of a close is
swallowed, otherwise clicking an open combo closes it and immediately reopens
it so it never appears to close.

Sections gain a real collapse animation. CollapsibleSection::set_expanded
becomes virtual and its members protected so a design can animate without that
class anticipating how. The animation drives setFixedHeight rather than
maximumHeight, which Qt clamps upward so the body springs to full size for a
frame; disables the body layout for the duration, since a live layout reads a
shrinking parent as a squeeze and compresses the rows instead of clipping them;
and measures the expanded height while the body is actually laid out, because
sizeHint() on a hidden widget overestimates and the animation overshoots.

Also: bind() is renamed bind_control(). An unqualified bind<T>() call with a
std type pulls std::bind in through ADL and fails deep inside <functional>,
nowhere near the mistake.

ui_font() probes for a neutral grotesque instead of taking whatever the
platform hands out, falling through to the platform default when none is
installed.
Sections are drawn as a card: the header and the body each paint the same
background, with the section painting one underneath to fill the few pixels
between them. All three name the colour explicitly.

Nothing else works here. Painting only the card is invisible, because both
children are opaque and cover it -- a magenta test card showed through as a
three-pixel line and nothing more. Making the header "transparent" so the card
shows is equally unreliable: Qt falls back to painting a QToolButton itself for
any state a stylesheet does not name, and :checked is every expanded section, so
the header came out a different shade no matter what the card was set to.

Collapsing moves a ClipBox's reveal rather than resizing anything. The body is
kept at its natural size and cropped, because a child is clipped to its parent
for free. Constraining the body instead fails three different ways: a fixed
height has to land exactly on the final size hint or it snaps when released, a
maximumHeight is clamped back up by the body's own Fixed policy, and leaving the
body Preferred lets the parent layout redistribute space across every section,
so one animating section nudges all of them -- worse the further down the column
they sit, because the error accumulates.

The clip box also watches the body for LayoutRequest. A widget whose height
follows its width settles after first layout, and without this it stays cropped
at whatever it measured first.

Combo popups reveal by clipping inside a fixed translucent window rather than
resizing the window itself: the platform enforces a minimum window size and
coalesces rapid resizes, so an animated geometry simply snaps. WA_TranslucentBackground
has to be set before the native window exists, and without WA_NoSystemBackground
beside it, or the surface is left undefined rather than clear.

PointsCanvas derives its height from its width instead of a hardcoded 220. The
point domain is square, so a fixed height only matched it at one panel width.
This changes the stock widget for every consumer, not only this design.
The factory read kPaletteTheme directly instead of the theme set for the
design, so set_theme() had no effect on section chrome. A host that picks
a colourway got it on the rows but not on the section cards, and the two
drew different greys.

Resolved per section rather than captured, since the theme can be set
after the design registers.
feat(qt): pluggable widget designs for attribute rows
@otto-link
otto-link merged commit 6236a7a into main Sep 4, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants